Micron Document
baubs git

Node / mirrors / respira / commits / 99d32f9

Commit 99d32f90292dcb4f5ab154d8b15f90797c1fb978


Parents : b008fd3
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-25T21:24:46+01:00

feature: Extract canvas state management into custom React hooks

Create two specialized custom hooks for PatternCanvas:

**useCanvasViewport (166 lines)**
- Manages canvas zoom, pan, and container resize
- Handles wheel zoom and button zoom operations
- Tracks container size with ResizeObserver
- Calculates initial scale when pattern changes
- Returns: stagePos, stageScale, containerSize, zoom handlers

**usePatternTransform (179 lines)**
- Manages pattern position, rotation, and transform state
- Handles drag and transform end events
- Syncs local state with global pattern store
- Manages transformer attachment/detachment
- Returns: offsets, rotation, refs, event handlers

Benefits:
- Reduced PatternCanvas from 608 to 388 lines (-220 lines, -36%)
- Better separation of concerns (viewport vs pattern transform)
- Reusable hooks for other canvas components
- Easier to test state management logic in isolation
- Cleaner component with focused responsibility

Combined refactoring impact (Phase 1+2+3):
- Original: 786 lines in single file
- After Phase 3: 388 lines main + hooks + components
- Total reduction: -398 lines (-51%)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

Changes

3 files changed, 387 insertions(+), 257 deletions(-)


Diff

diff --git a/src/components/PatternCanvas/PatternCanvas.tsx b/src/components/PatternCanvas/PatternCanvas.tsx
index 948332b..665fb69 100644
--- a/src/components/PatternCanvas/PatternCanvas.tsx
+++ b/src/components/PatternCanvas/PatternCanvas.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, useCallback } from "react";
+import { useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import {
useMachineStore,
@@ -7,10 +7,7 @@ import {
import { usePatternStore } from "../../stores/usePatternStore";
import { Stage, Layer, Group, Transformer } from "react-konva";
import Konva from "konva";
-import type { KonvaEventObject } from "konva/lib/Node";
import { PhotoIcon } from "@heroicons/react/24/solid";
-import type { PesPatternData } from "../../formats/import/pesImporter";
-import { calculateInitialScale } from "../../utils/konvaRenderers";
import {
Grid,
Origin,
@@ -29,11 +26,12 @@ import {
import {
calculatePatternCenter,
convertPenStitchesToPesFormat,
- calculateZoomToPoint,
} from "./patternCanvasHelpers";
import { ThreadLegend } from "./ThreadLegend";
import { PatternPositionIndicator } from "./PatternPositionIndicator";
import { ZoomControls } from "./ZoomControls";
+import { useCanvasViewport } from "../../hooks/useCanvasViewport";
+import { usePatternTransform } from "../../hooks/usePatternTransform";
export function PatternCanvas() {
// Machine store
@@ -70,260 +68,42 @@ export function PatternCanvas() {
const patternUploaded = usePatternUploaded();
const containerRef = useRef<HTMLDivElement>(null);
const stageRef = useRef<Konva.Stage | null>(null);
- const patternGroupRef = useRef<Konva.Group | null>(null);
- const transformerRef = useRef<Konva.Transformer | null>(null);
- const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
- const [stageScale, setStageScale] = useState(1);
- const [localPatternOffset, setLocalPatternOffset] = useState(
- initialPatternOffset || { x: 0, y: 0 },
- );
- const [localPatternRotation, setLocalPatternRotation] = useState(
- initialPatternRotation || 0,
- );
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
- const initialScaleRef = useRef<number>(1);
- const prevPesDataRef = useRef<PesPatternData | null>(null);
-
- // Update pattern offset when initialPatternOffset changes
- if (
- initialPatternOffset &&
- (localPatternOffset.x !== initialPatternOffset.x ||
- localPatternOffset.y !== initialPatternOffset.y)
- ) {
- setLocalPatternOffset(initialPatternOffset);
- console.log(
- "[PatternCanvas] Restored pattern offset:",
- initialPatternOffset,
- );
- }
-
- // Update pattern rotation when initialPatternRotation changes
- if (
- initialPatternRotation !== undefined &&
- localPatternRotation !== initialPatternRotation
- ) {
- setLocalPatternRotation(initialPatternRotation);
- }
-
- // Track container size
- useEffect(() => {
- if (!containerRef.current) return;
-
- const updateSize = () => {
- if (containerRef.current) {
- const width = containerRef.current.clientWidth;
- const height = containerRef.current.clientHeight;
- setContainerSize({ width, height });
- }
- };
-
- // Initial size
- updateSize();
-
- // Watch for resize
- const resizeObserver = new ResizeObserver(updateSize);
- resizeObserver.observe(containerRef.current);
-
- return () => resizeObserver.disconnect();
- }, []);
-
- // Calculate and store initial scale when pattern or hoop changes
- useEffect(() => {
- // Use whichever pattern is available (uploaded or original)
- const currentPattern = uploadedPesData || pesData;
- if (!currentPattern || containerSize.width === 0) {
- prevPesDataRef.current = null;
- return;
- }
-
- // Only recalculate if pattern changed
- if (prevPesDataRef.current !== currentPattern) {
- prevPesDataRef.current = currentPattern;
-
- const { bounds } = currentPattern;
- const viewWidth = machineInfo
- ? machineInfo.maxWidth
- : bounds.maxX - bounds.minX;
- const viewHeight = machineInfo
- ? machineInfo.maxHeight
- : bounds.maxY - bounds.minY;
-
- const initialScale = calculateInitialScale(
- containerSize.width,
- containerSize.height,
- viewWidth,
- viewHeight,
- );
- initialScaleRef.current = initialScale;
-
- // Reset view when pattern changes
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setStageScale(initialScale);
- setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
- }
- }, [pesData, uploadedPesData, machineInfo, containerSize]);
-
- // Wheel zoom handler
- const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
- e.evt.preventDefault();
-
- const stage = e.target.getStage();
- if (!stage) return;
-
- const pointer = stage.getPointerPosition();
- if (!pointer) return;
-
- const scaleBy = 1.1;
- const direction = e.evt.deltaY > 0 ? -1 : 1;
-
- setStageScale((oldScale) => {
- const newScale = Math.max(
- 0.1,
- Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
- );
-
- // Zoom towards pointer
- setStagePos((prevPos) =>
- calculateZoomToPoint(oldScale, newScale, pointer, prevPos),
- );
-
- return newScale;
- });
- }, []);
-
- // Zoom control handlers
- const handleZoomIn = useCallback(() => {
- setStageScale((oldScale) => {
- const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
-
- // Zoom towards center of viewport
- const center = {
- x: containerSize.width / 2,
- y: containerSize.height / 2,
- };
- setStagePos((prevPos) =>
- calculateZoomToPoint(oldScale, newScale, center, prevPos),
- );
-
- return newScale;
- });
- }, [containerSize]);
-
- const handleZoomOut = useCallback(() => {
- setStageScale((oldScale) => {
- const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
-
- // Zoom towards center of viewport
- const center = {
- x: containerSize.width / 2,
- y: containerSize.height / 2,
- };
- setStagePos((prevPos) =>
- calculateZoomToPoint(oldScale, newScale, center, prevPos),
- );
-
- return newScale;
- });
- }, [containerSize]);
-
- const handleZoomReset = useCallback(() => {
- const initialScale = initialScaleRef.current;
- setStageScale(initialScale);
- setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
- }, [containerSize]);
-
- const handleCenterPattern = useCallback(() => {
- if (!pesData) return;
-
- const { bounds } = pesData;
- const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
- const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
-
- setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
- setPatternOffset(centerOffsetX, centerOffsetY);
- }, [pesData, setPatternOffset]);
-
- // Pattern drag handlers
- const handlePatternDragEnd = useCallback(
- (e: Konva.KonvaEventObject<DragEvent>) => {
- const newOffset = {
- x: e.target.x(),
- y: e.target.y(),
- };
- setLocalPatternOffset(newOffset);
- setPatternOffset(newOffset.x, newOffset.y);
- },
- [setPatternOffset],
- );
-
- // Attach/detach transformer based on state
- const attachTransformer = useCallback(() => {
- if (!transformerRef.current || !patternGroupRef.current) {
- console.log(
- "[PatternCanvas] Cannot attach transformer - refs not ready",
- {
- hasTransformer: !!transformerRef.current,
- hasPatternGroup: !!patternGroupRef.current,
- },
- );
- return;
- }
-
- if (!patternUploaded && !isUploading) {
- console.log("[PatternCanvas] Attaching transformer");
- transformerRef.current.nodes([patternGroupRef.current]);
- transformerRef.current.getLayer()?.batchDraw();
- } else {
- console.log("[PatternCanvas] Detaching transformer");
- transformerRef.current.nodes([]);
- }
- }, [patternUploaded, isUploading]);
-
- // Call attachTransformer when conditions change
- useEffect(() => {
- attachTransformer();
- }, [attachTransformer, pesData]);
-
- // Sync node rotation with state (important for when rotation is reset to 0 after upload)
- useEffect(() => {
- if (patternGroupRef.current) {
- patternGroupRef.current.rotation(localPatternRotation);
- }
- }, [localPatternRotation]);
-
- // Handle transformer rotation - just store the angle, apply at upload time
- const handleTransformEnd = useCallback(
- (e: KonvaEventObject<Event>) => {
- if (!pesData) return;
-
- const node = e.target;
- // Read rotation from the node
- const totalRotation = node.rotation();
- const normalizedRotation = ((totalRotation % 360) + 360) % 360;
-
- setLocalPatternRotation(normalizedRotation);
-
- // Also read position in case the Transformer affected it
- const newOffset = {
- x: node.x(),
- y: node.y(),
- };
- setLocalPatternOffset(newOffset);
-
- // Store rotation angle and position
- setPatternRotation(normalizedRotation);
- setPatternOffset(newOffset.x, newOffset.y);
+ // Canvas viewport (zoom, pan, container size)
+ const {
+ stagePos,
+ stageScale,
+ containerSize,
+ handleWheel,
+ handleZoomIn,
+ handleZoomOut,
+ handleZoomReset,
+ } = useCanvasViewport({
+ containerRef,
+ pesData,
+ uploadedPesData,
+ machineInfo,
+ });
- console.log(
- "[Canvas] Transform end - rotation:",
- normalizedRotation,
- "degrees, position:",
- newOffset,
- );
- },
- [setPatternRotation, setPatternOffset, pesData],
- );
+ // Pattern transform (position, rotation, drag/transform)
+ const {
+ localPatternOffset,
+ localPatternRotation,
+ patternGroupRef,
+ transformerRef,
+ attachTransformer,
+ handleCenterPattern,
+ handlePatternDragEnd,
+ handleTransformEnd,
+ } = usePatternTransform({
+ pesData,
+ initialPatternOffset,
+ initialPatternRotation,
+ setPatternOffset,
+ setPatternRotation,
+ patternUploaded,
+ isUploading,
+ });
const hasPattern = pesData || uploadedPesData;
const borderColor = hasPattern

diff --git a/src/hooks/useCanvasViewport.ts b/src/hooks/useCanvasViewport.ts
new file mode 100644
index 0000000..b300945
--- /dev/null
+++ b/src/hooks/useCanvasViewport.ts
@@ -0,0 +1,179 @@
+/**
+ * useCanvasViewport Hook
+ *
+ * Manages canvas viewport state including zoom, pan, and container size
+ * Handles wheel zoom and button zoom operations
+ */
+
+import {
+ useState,
+ useEffect,
+ useCallback,
+ useRef,
+ type RefObject,
+} from "react";
+import type Konva from "konva";
+import type { PesPatternData } from "../formats/import/pesImporter";
+import type { MachineInfo } from "../types/machine";
+import { calculateInitialScale } from "../utils/konvaRenderers";
+import { calculateZoomToPoint } from "../components/PatternCanvas/patternCanvasHelpers";
+
+interface UseCanvasViewportOptions {
+ containerRef: RefObject<HTMLDivElement | null>;
+ pesData: PesPatternData | null;
+ uploadedPesData: PesPatternData | null;
+ machineInfo: MachineInfo | null;
+}
+
+export function useCanvasViewport({
+ containerRef,
+ pesData,
+ uploadedPesData,
+ machineInfo,
+}: UseCanvasViewportOptions) {
+ const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
+ const [stageScale, setStageScale] = useState(1);
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+ const initialScaleRef = useRef<number>(1);
+ const prevPesDataRef = useRef<PesPatternData | null>(null);
+
+ // Track container size with ResizeObserver
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const updateSize = () => {
+ if (containerRef.current) {
+ const width = containerRef.current.clientWidth;
+ const height = containerRef.current.clientHeight;
+ setContainerSize({ width, height });
+ }
+ };
+
+ // Initial size
+ updateSize();
+
+ // Watch for resize
+ const resizeObserver = new ResizeObserver(updateSize);
+ resizeObserver.observe(containerRef.current);
+
+ return () => resizeObserver.disconnect();
+ }, [containerRef]);
+
+ // Calculate and store initial scale when pattern or hoop changes
+ useEffect(() => {
+ // Use whichever pattern is available (uploaded or original)
+ const currentPattern = uploadedPesData || pesData;
+ if (!currentPattern || containerSize.width === 0) {
+ prevPesDataRef.current = null;
+ return;
+ }
+
+ // Only recalculate if pattern changed
+ if (prevPesDataRef.current !== currentPattern) {
+ prevPesDataRef.current = currentPattern;
+
+ const { bounds } = currentPattern;
+ const viewWidth = machineInfo
+ ? machineInfo.maxWidth
+ : bounds.maxX - bounds.minX;
+ const viewHeight = machineInfo
+ ? machineInfo.maxHeight
+ : bounds.maxY - bounds.minY;
+
+ const initialScale = calculateInitialScale(
+ containerSize.width,
+ containerSize.height,
+ viewWidth,
+ viewHeight,
+ );
+ initialScaleRef.current = initialScale;
+
+ // Reset view when pattern changes
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setStageScale(initialScale);
+ setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
+ }
+ }, [pesData, uploadedPesData, machineInfo, containerSize]);
+
+ // Wheel zoom handler
+ const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
+ e.evt.preventDefault();
+
+ const stage = e.target.getStage();
+ if (!stage) return;
+
+ const pointer = stage.getPointerPosition();
+ if (!pointer) return;
+
+ const scaleBy = 1.1;
+ const direction = e.evt.deltaY > 0 ? -1 : 1;
+
+ setStageScale((oldScale) => {
+ const newScale = Math.max(
+ 0.1,
+ Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
+ );
+
+ // Zoom towards pointer
+ setStagePos((prevPos) =>
+ calculateZoomToPoint(oldScale, newScale, pointer, prevPos),
+ );
+
+ return newScale;
+ });
+ }, []);
+
+ // Zoom control handlers
+ const handleZoomIn = useCallback(() => {
+ setStageScale((oldScale) => {
+ const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
+
+ // Zoom towards center of viewport
+ const center = {
+ x: containerSize.width / 2,
+ y: containerSize.height / 2,
+ };
+ setStagePos((prevPos) =>
+ calculateZoomToPoint(oldScale, newScale, center, prevPos),
+ );
+
+ return newScale;
+ });
+ }, [containerSize]);
+
+ const handleZoomOut = useCallback(() => {
+ setStageScale((oldScale) => {
+ const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
+
+ // Zoom towards center of viewport
+ const center = {
+ x: containerSize.width / 2,
+ y: containerSize.height / 2,
+ };
+ setStagePos((prevPos) =>
+ calculateZoomToPoint(oldScale, newScale, center, prevPos),
+ );
+
+ return newScale;
+ });
+ }, [containerSize]);
+
+ const handleZoomReset = useCallback(() => {
+ const initialScale = initialScaleRef.current;
+ setStageScale(initialScale);
+ setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
+ }, [containerSize]);
+
+ return {
+ // State
+ stagePos,
+ stageScale,
+ containerSize,
+
+ // Handlers
+ handleWheel,
+ handleZoomIn,
+ handleZoomOut,
+ handleZoomReset,
+ };
+}

diff --git a/src/hooks/usePatternTransform.ts b/src/hooks/usePatternTransform.ts
new file mode 100644
index 0000000..8e8f92f
--- /dev/null
+++ b/src/hooks/usePatternTransform.ts
@@ -0,0 +1,171 @@
+/**
+ * usePatternTransform Hook
+ *
+ * Manages pattern transformation state including position, rotation, and drag/transform handling
+ * Syncs local state with global pattern store
+ */
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import type Konva from "konva";
+import type { KonvaEventObject } from "konva/lib/Node";
+import type { PesPatternData } from "../formats/import/pesImporter";
+
+interface UsePatternTransformOptions {
+ pesData: PesPatternData | null;
+ initialPatternOffset: { x: number; y: number };
+ initialPatternRotation: number;
+ setPatternOffset: (x: number, y: number) => void;
+ setPatternRotation: (rotation: number) => void;
+ patternUploaded: boolean;
+ isUploading: boolean;
+}
+
+export function usePatternTransform({
+ pesData,
+ initialPatternOffset,
+ initialPatternRotation,
+ setPatternOffset,
+ setPatternRotation,
+ patternUploaded,
+ isUploading,
+}: UsePatternTransformOptions) {
+ const [localPatternOffset, setLocalPatternOffset] = useState(
+ initialPatternOffset || { x: 0, y: 0 },
+ );
+ const [localPatternRotation, setLocalPatternRotation] = useState(
+ initialPatternRotation || 0,
+ );
+
+ const patternGroupRef = useRef<Konva.Group | null>(null);
+ const transformerRef = useRef<Konva.Transformer | null>(null);
+
+ // Update pattern offset when initialPatternOffset changes
+ if (
+ initialPatternOffset &&
+ (localPatternOffset.x !== initialPatternOffset.x ||
+ localPatternOffset.y !== initialPatternOffset.y)
+ ) {
+ setLocalPatternOffset(initialPatternOffset);
+ console.log(
+ "[PatternTransform] Restored pattern offset:",
+ initialPatternOffset,
+ );
+ }
+
+ // Update pattern rotation when initialPatternRotation changes
+ if (
+ initialPatternRotation !== undefined &&
+ localPatternRotation !== initialPatternRotation
+ ) {
+ setLocalPatternRotation(initialPatternRotation);
+ }
+
+ // Attach/detach transformer based on state
+ const attachTransformer = useCallback(() => {
+ if (!transformerRef.current || !patternGroupRef.current) {
+ console.log(
+ "[PatternTransform] Cannot attach transformer - refs not ready",
+ {
+ hasTransformer: !!transformerRef.current,
+ hasPatternGroup: !!patternGroupRef.current,
+ },
+ );
+ return;
+ }
+
+ if (!patternUploaded && !isUploading) {
+ console.log("[PatternTransform] Attaching transformer");
+ transformerRef.current.nodes([patternGroupRef.current]);
+ transformerRef.current.getLayer()?.batchDraw();
+ } else {
+ console.log("[PatternTransform] Detaching transformer");
+ transformerRef.current.nodes([]);
+ }
+ }, [patternUploaded, isUploading]);
+
+ // Call attachTransformer when conditions change
+ useEffect(() => {
+ attachTransformer();
+ }, [attachTransformer, pesData]);
+
+ // Sync node rotation with state (important for when rotation is reset to 0 after upload)
+ useEffect(() => {
+ if (patternGroupRef.current) {
+ patternGroupRef.current.rotation(localPatternRotation);
+ }
+ }, [localPatternRotation]);
+
+ // Center pattern in hoop
+ const handleCenterPattern = useCallback(() => {
+ if (!pesData) return;
+
+ const { bounds } = pesData;
+ const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
+ const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
+
+ setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
+ setPatternOffset(centerOffsetX, centerOffsetY);
+ }, [pesData, setPatternOffset]);
+
+ // Pattern drag handlers
+ const handlePatternDragEnd = useCallback(
+ (e: Konva.KonvaEventObject<DragEvent>) => {
+ const newOffset = {
+ x: e.target.x(),
+ y: e.target.y(),
+ };
+ setLocalPatternOffset(newOffset);
+ setPatternOffset(newOffset.x, newOffset.y);
+ },
+ [setPatternOffset],
+ );
+
+ // Handle transformer rotation - just store the angle, apply at upload time
+ const handleTransformEnd = useCallback(
+ (e: KonvaEventObject<Event>) => {
+ if (!pesData) return;
+
+ const node = e.target;
+ // Read rotation from the node
+ const totalRotation = node.rotation();
+ const normalizedRotation = ((totalRotation % 360) + 360) % 360;
+
+ setLocalPatternRotation(normalizedRotation);
+
+ // Also read position in case the Transformer affected it
+ const newOffset = {
+ x: node.x(),
+ y: node.y(),
+ };
+ setLocalPatternOffset(newOffset);
+
+ // Store rotation angle and position
+ setPatternRotation(normalizedRotation);
+ setPatternOffset(newOffset.x, newOffset.y);
+
+ console.log(
+ "[PatternTransform] Transform end - rotation:",
+ normalizedRotation,
+ "degrees, position:",
+ newOffset,
+ );
+ },
+ [setPatternRotation, setPatternOffset, pesData],
+ );
+
+ return {
+ // State
+ localPatternOffset,
+ localPatternRotation,
+
+ // Refs
+ patternGroupRef,
+ transformerRef,
+
+ // Handlers
+ attachTransformer,
+ handleCenterPattern,
+ handlePatternDragEnd,
+ handleTransformEnd,
+ };
+}

Served by rngit 1.3.3 - Generated in 0.05s